/**
 * DSM - Distributed Search Manager
 * Developed by Milspec Research International Pty Ltd
 * $Author: skahl $
 * $Revision: 1.18 $
 * $Date: 2007/12/05 22:28:06 $
 * (c)Copyright 2004
 * education.au limited
 * DEST
 * All rights reserved.
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 * Redistributions of source code must retain the above copyright notice, this
 * list of conditions and the following disclaimer.
 * Redistributions in binary form must reproduce the above copyright notice,
 * this list of conditions and the following disclaimer in the documentation
 * and/or other materials provided with the distribution.
 * Neither the names education.au limited, DEST nor the names of its
 * contributors may be used to endorse or promote products derived from this
 * software without specific prior written permission.
 * To the extent permitted by law, the copyright owners of this software and
 * its contributors:
 * (i) exclude all warranties in relation to the software; and
 * (ii) exclude liability for all losses, costs, expenses and damages arising
 * in any way from the use of the software whether arising from or in
 * relation to breach of contract, negligence or any other tort, in equity
 * or otherwise. If the software is in breach of a warranty which is
 * implied by law, the copyright owners and contributors liability is
 * limited to the replacement of the software.
 *
 * @author	gsingh
 */

package au.edu.educationau.opensource.dsm.service;

import java.io.StringReader;
import java.util.Collection;
import java.util.Enumeration;
import java.util.Hashtable;
import java.util.Iterator;
import java.util.Vector;
import java.util.regex.Pattern;

import javax.servlet.http.HttpServletRequest;

import org.apache.commons.lang.ArrayUtils;
import org.apache.log4j.Logger;

import au.edu.educationau.opensource.dsm.DeusExMachina;
import au.edu.educationau.opensource.dsm.adapters.SearchAdapter;
import au.edu.educationau.opensource.dsm.obj.CQLCriteriaHandler;
import au.edu.educationau.opensource.dsm.obj.SMEvents;
import au.edu.educationau.opensource.dsm.obj.SearchCriteria;
import au.edu.educationau.opensource.dsm.obj.SearchResultSet;
import au.edu.educationau.opensource.dsm.util.EducationAuUtils;
import au.edu.educationau.opensource.dsm.util.StringUtils;
import au.edu.educationau.opensource.dsm.worker.AdapterGroups;
import au.edu.educationau.opensource.dsm.worker.ResultSetUnifier;
import au.edu.educationau.opensource.dsm.worker.SearchAdapterFactory;
import au.edu.educationau.opensource.dsm.worker.SearchJob;
import au.edu.educationau.opensource.dsm.worker.SearchTask;
import au.edu.educationau.opensource.dsm.worker.SpellCheckTask;
import au.edu.educationau.opensource.dsm.worker.ThesauriTask;

/**
 * This is the heart of the search manager. It is responsible for assembling
 * search, thesauri and spell check tasks into jobs using the search criteria
 * supplied. Also implements the search strategy.
 */

public final class SearchManagerService {
	static Logger logger = Logger.getLogger(SearchManagerService.class.getName());

	final Pattern SEARCH_STRATEGY_WAITN_REGEXP = Pattern.compile(SMEvents.SEARCH_STRATEGY_WAIT_N_SECONDS);
	private static SearchManagerService _instance = null;
	private Hashtable jobs = new Hashtable();
	/** The class display string */
	public static final String classStr = "o.m.d.m.SearchManagerService";
	private long reaperInterval = 1800000; // 1800 seconds or 30 minutes
	private Reaper reaper = null;

	/**
	 * Only constructor for the SearchManagerService
	 */
	private SearchManagerService() {
		this.reaperInterval = PropertiesService.getLongProperty("searchmanagerservice.reaper.interval", 1800000);
		this.reaper = new Reaper();
		Thread t = new Thread(reaper);
		t.start();
		logger.debug("SearchManagerService started ok.");
	}

	/** Returns the singleton instance of this service */
	public static SearchManagerService getInstance() {
		if (null == _instance) {
			_instance = new SearchManagerService();
		}

		return _instance;
	}

	/** Destroy all objects worthy of destruction */
	public void destroy() {
		try {
			jobs.clear();
		} catch (Exception oe) {
		}
		try {
			reaper.stopReaper();
		} catch (Exception oe) {
		}
		logger.debug("SearchManagerService shutdown ok.");
	}

	/**
	 * Return the job with this token
	 * 
	 * @param token
	 */
	public SearchJob getJob(long token) {
		return (SearchJob) jobs.get(new Long(token));
	}

	/**
	 * Remove a job with this token
	 * 
	 * @param token
	 */
	public synchronized void removeJob(long token) {
		jobs.remove(new Long(token));
	}

	/**
	 * Check if a job exists with this token
	 * 
	 * @param token
	 */
	public boolean jobExists(long token) {
		return jobs.containsKey(new Long(token));
	}

	/**
	 * Build search criteria from the request object. The token is sent
	 * separately to prevent spoofing.
	 * 
	 * @param request
	 * @param token
	 */
	public SearchCriteria buildSearchCriteria(HttpServletRequest request, long token) {
		SearchCriteria criteria = new SearchCriteria();
		String[] sources = StringUtils.choiceStringArray(request.getParameterValues("sr"), null, new String[] { "edna" });
		criteria.setToken(token);
		// TODO: make ASCII conversion conditional or configurable
		criteria.setQuery(StringUtils.nnString(StringUtils.toASCII(request.getParameter("q"))));
		criteria.setSources(sources);
		criteria.setThesauri(request.getParameterValues("th"));
		criteria.setCaseSensitive(StringUtils.nnString(request.getParameter("cs")).equalsIgnoreCase("true"));
		criteria.setKeywordConstraint(StringUtils.checkString(request.getParameter("kc"), "all"));
		criteria.setMode(StringUtils.checkString(request.getParameter("mode"), "search"));
		criteria.setSearchStrategy(StringUtils.checkString(request.getParameter("ss"), SMEvents.SEARCH_STRATEGY_WAIT_FOR_FASTEST));
		criteria.setMaxResults(EducationAuUtils.getInteger(StringUtils.checkString(request.getParameter("mr"), "100"), 50, 200));
		criteria.setUserCode(StringUtils.checkString(request.getParameter("user"), ""));
		String cqlQuery = buildCQLFromQuery(criteria.getQueryArray(), criteria.getKeywordConstraint());
		criteria.setCQLQuery(cqlQuery);

		if (sources.length > 0) {
			criteria.setSources(EducationAuUtils.removeDuplicates(sources));
		}

		// This logic could be replaced by an adapter.group entry in the config
		// file. See AdapterGroups class.
		if (sources[0].equals("_alled")) {
			sources = new String[] { "edna", "ednapages", "merlot", "voced", "gem", "_alled" };
			criteria.setSources(sources);
		}

		if (sources[0].equals("all") && sources.length == 1) {
			sources = SearchAdapterFactory.getSources();
			criteria.setSources(sources);
		} else {
			sources = expandAdapterGroups(sources);
			criteria.setSources(sources);
		}

		// handle custom params
		Collection adapterGroups = AdapterGroups.getGroups();
		for (Enumeration e = request.getParameterNames(); e.hasMoreElements();) {
			String adapterParameter = (String) e.nextElement();
			if (adapterParameter == null) {
				// ignore
			} else {
				String[] vals = request.getParameterValues(adapterParameter);
				if (null != vals) {
					// handle standard adapter params
					for (int i = 0; i < sources.length; i++) {
						String source = sources[i];
						if (adapterParameter.toLowerCase().startsWith(source + ".")) {
							criteria.addCustomParamList(adapterParameter, vals);
						}
					} // for

					/*
					 * Handle adapter group params. ie. turn an adapter group
					 * params into standard custom params for each adapter in
					 * the group that is present in the sources array for this
					 * search.
					 */
					for (Iterator it = adapterGroups.iterator(); it.hasNext();) {
						String adapterGroup = (String) it.next();
						if (adapterParameter.startsWith(adapterGroup + ".")) {
							String[] adaptersForGroup = AdapterGroups.getAdapterNames(adapterGroup);
							if (adaptersForGroup != null) {
								String paramName = adapterParameter.substring(adapterParameter.indexOf("."));
								for (int j = 0; j < adaptersForGroup.length; j++) {
									if (ArrayUtils.contains(sources, adaptersForGroup[j])) {
										criteria.addCustomParamList(adaptersForGroup[j] + paramName, vals);
									}
								}
							}
						}
					} // for
				} // if
			} // if
		} // for

		if (logger.isInfoEnabled()) {
			logger.info("REQUEST " + criteria.getUserCode() + " " + token + " " + request.getRequestURL().append("?").append(request.getQueryString()).toString());
		}

		return criteria;
	}

	/**
	 * Expands all adapter groups in the argument array. When an adapter group
	 * is found, its associated adapter names are added to the end of the array.
	 * The original group name is retained so that calling code can still find
	 * it.
	 */
	private String[] expandAdapterGroups(String[] sources) {
		for (int i = 0; i < sources.length; i++) {
			String[] adaptersForGroup = AdapterGroups.getAdapterNames(sources[i]);
			if (adaptersForGroup != null) {
				sources = (String[]) ArrayUtils.addAll(sources, adaptersForGroup);
			}
		}
		return sources;
	}

	/**
	 * Build search criteria for SRU version The token is sent separately to
	 * prevent spoofing.
	 * 
	 * @param request
	 * @param token
	 */
	public SearchCriteria buildSRUSearchCriteria(HttpServletRequest request, long token, String operation) {
		SearchCriteria criteria = buildCQLSRWSearchCriteria(request, token, operation, null);
		String cqlQuery = buildCQLFromQuery(criteria.getQueryArray(), criteria.getKeywordConstraint());
		criteria.setCQLQuery(cqlQuery);
		return criteria;
	}

	/**
	 * Build search criteria for SRU version using the CQL Tree supplied. If
	 * there is no tree, criteria is built from params The token is sent
	 * separately to prevent spoofing.
	 * 
	 * @param request
	 * @param token
	 * @param criteria
	 *            the starting criteria.
	 */
	public SearchCriteria buildCQLSRWSearchCriteria(HttpServletRequest request, long token, String operation, SearchCriteria criteria) {
		if (null == criteria) {
			criteria = new SearchCriteria();
		}

		String[] sources = StringUtils.choiceStringArray(request.getParameterValues("x-sr"), null, new String[] { "edna" });
		criteria.setToken(token);
		criteria.setQuery(StringUtils.nnString(StringUtils.toASCII(request.getParameter("query"))));
		criteria.setSources(sources);
		criteria.setThesauri(request.getParameterValues("x-th"));
		criteria.setCaseSensitive(StringUtils.nnString(request.getParameter("x-cs")).equalsIgnoreCase("true"));
		criteria.setKeywordConstraint(StringUtils.checkString(request.getParameter("x-kc"), "all"));
		criteria.setMode(StringUtils.checkString(request.getParameter("x-mode"), "search"));
		criteria.setSearchStrategy(StringUtils.checkString(request.getParameter("x-ss"), SMEvents.SEARCH_STRATEGY_WAIT_FOR_FASTEST));
		criteria.setMaxResults(EducationAuUtils.getInteger(StringUtils.checkString(request.getParameter("x-mr"), "100"), 10, 500));
		criteria.setUserCode(StringUtils.checkString(request.getParameter("x-user"), ""));

		if (sources.length > 0) {
			criteria.setSources(EducationAuUtils.removeDuplicates(sources));
		}

		if (operation.equals("explain")) {
			criteria.setQuery("");
			sources = SearchAdapterFactory.getSources();
			criteria.setSources(sources);
		} else if (sources[0].equals("all") && sources.length == 1) {
			sources = SearchAdapterFactory.getSources();
			criteria.setSources(sources);
		} else {
			for (Enumeration e = request.getParameterNames(); e.hasMoreElements();) {
				String adapterParameter = (String) e.nextElement();
				for (int i = 0; i < sources.length; i++) {
					if (null != adapterParameter && adapterParameter.toLowerCase().startsWith("x-" + sources[i] + ".")) {
						String[] vals = request.getParameterValues(adapterParameter);
						if (null != vals) {
							criteria.addCustomParamList(adapterParameter.substring(2), vals);
						}
					} // if
				} // for
			} // for
		}

		if (logger.isInfoEnabled()) {
			logger.info("REQUEST " + criteria.getUserCode() + " " + token + " SRU " + operation + " " + request.getRequestURL().append("?").append(request.getQueryString()).toString());
		}

		return criteria;
	}

	/**
	 * Update the criteria with CQL search data
	 */
	public SearchCriteria processCQLQuery(SearchCriteria criteria) {
		if (null != criteria.getCQLTree() && criteria.getCQLTree().length() > 0) {
			// Process the CQL Tree here and modify the criteria to suit
			CQLCriteriaHandler handler = new CQLCriteriaHandler();
			handler = (CQLCriteriaHandler) ResultSetUnifier.processXML(new StringReader(criteria.getCQLTree()), handler);
			criteria.setQuery(handler.getTerms());
			criteria.setKeywordConstraint(handler.getKeywordConstraint());
		}

		return criteria;
	}

	/**
	 * Build CQL Query from keywords and constraint
	 * 
	 * @param criteria
	 * @return cqlQuery
	 */
	public String buildCQLFromQuery(String[] queryArray, String keywordConstraint) {
		StringBuffer cqlQuery = new StringBuffer();
		if (keywordConstraint.equals("all")) {
			for (int i = 0; i < queryArray.length; i++) {
				cqlQuery.append(queryArray[i]);
				if (i < queryArray.length - 1) {
					cqlQuery.append("%20and%20");
				}
			}
		} else if (keywordConstraint.equals("any")) {
			for (int i = 0; i < queryArray.length; i++) {
				cqlQuery.append(queryArray[i]);
				if (i < queryArray.length - 1) {
					cqlQuery.append("%20or%20");
				}
			}
		} else if (keywordConstraint.equals("phrase")) {
			cqlQuery.append("%22");
			for (int i = 0; i < queryArray.length; i++) {
				cqlQuery.append(queryArray[i]);
				if (i < queryArray.length - 1) {
					cqlQuery.append("%20");
				}
			}
			cqlQuery.append("%22");
		}
		return cqlQuery.toString();
	}

	/**
	 * Assembles tasks for the specific mode and criteria
	 * 
	 * @param criteria
	 *            the assembled search criteria
	 */
	public boolean search(SearchCriteria criteria) {
		// examine search criteria here
		ThesauriTask tTask = null;
		SpellCheckTask sTask = null;
		long token = criteria.getToken();

		if (jobs.containsKey(new Long(token))) {
			return false;
		}

		if (criteria.getMode().equals("search")) {
			SearchJob job = new SearchJob(criteria);
			Vector tasks = new Vector(5); // up this value when there are more
											// adapters
			String[] sources = criteria.getSources();

			// build SearchJob and add to this manager's cache
			for (int i = 0; i < sources.length; i++) {
				SearchAdapter adapter = SearchAdapterFactory.getAdapter(sources[i]);
				if (null != adapter) {
					adapter.setSearchCriteria(criteria);
					adapter.enumerateAdapterInstanceTag(i);
					tasks.add(new SearchTask(adapter));
				}
			}

			if (tasks.size() > 0) {
				SearchTask[] taskArray = new SearchTask[tasks.size()];
				tasks.copyInto(taskArray);
				job.loadTasks(taskArray);
				jobs.put(new Long(token), job);
			}

			// Build the search result set anyway
			SearchResultSet set = new SearchResultSet(criteria.getSources());
			try {
				DeusExMachina._SearchResultService().createEntry(token, set);
			} catch (Exception eo) {
				logger.error("Error while building search job", eo);
				return false;
			}

			if (PropertiesService.getBooleanProperty("search.spellchecker", false)) {
				// Kick off the spelling corrections here
				try {
					sTask = DeusExMachina._SpellCheckService().buildSpellCheckTask(criteria);
					DeusExMachina._ThreadPool().execute(sTask);
				} catch (java.lang.InterruptedException ie) {
					logger.error("Interrrupted while spellchecking", ie);
				}
			}

			// Kick off the thesauri lookup here
			if (PropertiesService.getBooleanProperty("search.thesauri.lookup", true) && criteria.getThesauri().length > 0) {
				tTask = new ThesauriTask(criteria);
				try {
					DeusExMachina._ThreadPool().execute(tTask);
				} catch (java.lang.InterruptedException ie) {
					logger.error("Interrrupted while getting thesauri in thread", ie);
				}
			}

			if (tasks.size() <= 0 || criteria.getQuery().length() <= 0) {
				DeusExMachina._SearchResultService().flagEntryComplete(token);
				return false;
			}

			// Kick off the search job here
			try {
				DeusExMachina._ThreadPool().execute(job);
			} catch (java.lang.InterruptedException ie) {
				return false;
			}

			if (criteria.getSearchStrategy().equals(SMEvents.SEARCH_STRATEGY_WAIT_FOR_ALL)) {
				/** WAIT FOR ALL RESULTS BEFORE RETURNING */
				while (!job.isFinished() || (null != tTask && !tTask.isFinished())) {
					try {
						Thread.currentThread().sleep(100);
					} catch (InterruptedException ie) {
					}
				}
				DeusExMachina._SearchResultService().flagEntryComplete(token);
				return true;
			} else if (criteria.getSearchStrategy().equals(SMEvents.SEARCH_STRATEGY_WAIT_FOR_FASTEST)) {
				/**
				 * WAIT FOR THE FIRST ADAPTER TO RETURN RESULTS OR FOR 15
				 * SECONDS. WHATEVER STATE IS REACHED FIRST THEN RETURN
				 */

				long timeout = (long) (PropertiesService.getIntegerProperty("searchstrategy.waitfast.timeout", 15) * 1000); // msecs
																															// to
																															// timeout
				while (!job.isOneTaskFinishedWithResults() && timeout > 0) {
					try {
						Thread.currentThread().sleep(150);
					} catch (InterruptedException ie) {
					}
					timeout -= 150;
				}
				if (job.isFinished() && (null == tTask || (null != tTask && tTask.isFinished()))) {
					DeusExMachina._SearchResultService().flagEntryComplete(token);
				}
				return true;
			} else if (SEARCH_STRATEGY_WAITN_REGEXP.matcher(criteria.getSearchStrategy()).matches()) {
				/**
				 * WAIT FOR THE FIRST ADAPTER TO RETURN RESULTS OR FOR 15
				 * SECONDS. WHATEVER STATE IS REACHED FIRST THEN RETURN
				 */
				long timeout = 0;
				long chosenTimeout = 0;

				try {
					timeout = Long.parseLong(criteria.getSearchStrategy().substring(4)) * 1000;
					long minTimeout = (long) (PropertiesService.getIntegerProperty("searchstrategy.waitn.min.timeout", 5) * 1000); // minimum
																																	// timeout
					long maxTimeout = (long) (PropertiesService.getIntegerProperty("searchstrategy.waitn.max.timeout", 60) * 1000); // maximum
																																	// timeout

					if (timeout < minTimeout) {
						timeout = minTimeout;
					} else if (timeout > maxTimeout) {
						timeout = maxTimeout;
					}
				} catch (NumberFormatException nfe) {
					timeout = (long) (PropertiesService.getIntegerProperty("searchstrategy.waitfast.timeout", 15) * 1000); // msecs
																															// to
																															// timeout
				}

				chosenTimeout = timeout;

				while (!job.isFinished() && timeout > 0) {
					try {
						Thread.currentThread().sleep(150);
					} catch (InterruptedException ie) {
					}
					timeout -= 150;
				}

				if (!job.isFinished()) {
					// force the job to complete.
					job.terminate();

					if (null != tTask && !tTask.isFinished()) {
						tTask.terminate();
					}

					DeusExMachina._SearchResultService().calculateTimeTaken(token, chosenTimeout, null);
				}

				DeusExMachina._SearchResultService().flagEntryComplete(token);

				return true;
			} else {
				return false;
			}
		} // if
		else if (criteria.getMode().equals("thesaurus")) {
			SearchResultSet set = new SearchResultSet(criteria.getSources());
			try {
				DeusExMachina._SearchResultService().createEntry(token, set);
			} catch (Exception eo) {
				logger.error("Error while building search job", eo);
				return false;
			}

			if (criteria.getThesauri().length > 0) {
				tTask = new ThesauriTask(criteria);
				try {
					DeusExMachina._ThreadPool().execute(tTask);
				} catch (java.lang.InterruptedException ie) {
					logger.error("Interrrupted while getting thesauri in thread", ie);
				}
			}

			while (null != tTask && !tTask.isFinished()) {
				try {
					Thread.currentThread().sleep(100);
				} catch (InterruptedException ie) {
				}
			}
			DeusExMachina._SearchResultService().flagEntryComplete(token);
			return true;
		} else if (criteria.getMode().equals("spelling")) {
			SearchResultSet set = new SearchResultSet(criteria.getSources());
			try {
				DeusExMachina._SearchResultService().createEntry(token, set);
			} catch (Exception eo) {
				logger.error("Error while building search job", eo);
				return false;
			}

			// Kick off the spelling corrections here
			try {
				sTask = DeusExMachina._SpellCheckService().buildSpellCheckTask(criteria);
				DeusExMachina._ThreadPool().execute(sTask);
			} catch (java.lang.InterruptedException ie) {
				return false;
			}
			while (null != sTask && !sTask.isFinished()) {
				try {
					Thread.currentThread().sleep(100);
				} catch (InterruptedException ie) {
				}
			}
			DeusExMachina._SearchResultService().flagEntryComplete(token);
			return true;
		} else {
			return false;
		}
	}

	/**
	 * Checks if the job matching this token is complete
	 * 
	 * @param token
	 */
	public void monitorJob(long token) {
		SearchJob job = (SearchJob) jobs.get(new Long(token));
		if (null != job) {
			if (job.isFinished()) {
				DeusExMachina._SearchResultService().flagEntryComplete(token);
			}
		}
	}

	/**
	 * Reaper class to cleanup the Job Queue
	 * 
	 */
	private class Reaper implements java.lang.Runnable {
		boolean runReaper = true;

		public void stopReaper() {
			runReaper = false;
		}

		public void run() {
			while (runReaper) {
				try {
					long now = System.currentTimeMillis();

					for (Enumeration jobKeys = jobs.keys(); jobKeys.hasMoreElements();) {
						Long thisToken = (Long) jobKeys.nextElement();
						if (now - ((SearchJob) jobs.get(thisToken)).getTimeCreated() >= reaperInterval) {
							removeJob(thisToken.longValue());
						}
					}
				} catch (Exception oe) {
					logger.error("Reaper: Error while maintaining result sets", oe);
				}

				try {
					Thread.currentThread().sleep(reaperInterval);
				} catch (InterruptedException ie) {
				}
			}
		}
	} // inner class Reaper

}

// -- EOF

